/*
 * Copyright 2012 Google Inc. All Rights Reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.google.fieldscope.api;

import java.text.ParseException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

/**
 * Represents an observation.
 * 
 * @author Travis Keep
 *
 */
public class Observation {
  
  private static final String STATION_ID = "station_id";
  private static final String DATA = "data";
  private static final String COLLECTION_DATE = "collection_date";
  private static final String ID = "id";
  private final long id;
  private final long stationId;
  private final long timestamp;
  private final Map<String, Object> items;
  
  /**
   * The Observation ID
   */
  public long getId() {
    return id;
  }

  /**
   * The station ID
   */
  public long getStationId() {
    return stationId;
  }

  /**
   * The time stamp since Jan 1, 1970 GMT.
   */
  public long getTimestamp() {
    return timestamp;
  }

  /**
   * The observation data. Key is the field name in the observation schema; value is the
   * observed value.
   */
  public Map<String, Object> getItems() {
    return items;
  }
  
  @Override
  public String toString() {
    return String.format("{id=%d, stationId=%d, timestamp=%d, items=%s}", id, stationId, timestamp, items);
  }
  
  /**
   * Serialize this object to a string.
   */
  public String marshall() {
    try {
      JSONObject json = new JSONObject();
      json.put(ID, id);
      json.put(STATION_ID, stationId);
      json.put(COLLECTION_DATE, timestamp);
      json.put(DATA, items);
      return json.toString();
    } catch (JSONException e) {
      throw new RuntimeException(e);
    }
  }
  
  /**
   * Reconstruct original object from a given value returned by {@link #marshall}.
   */
  public static Observation unmarshall(String s) {
    try {
      return newBuilder().unmarshall(new JSONObject(s)).build();
    } catch (JSONException e) {
      return null;
    }
  }
  
  /**
   * Create an empty builder.
   */
  public static Builder newBuilder() {
    return new Builder();
  }
  
  /**
   * Create builder whose fields are initialized to a specific observation.
   * @return
   */
  public static Builder newBuilder(Observation observation) {
    return new Builder().setObservation(observation);
  }
  
  public static class Builder {
    private long id;
    private long timestamp;
    private long stationId;
    private final MapBuilder<String, Object> items = MapBuilder.newBuilder();
    
    public Builder setObservation(Observation observation) {
      this.id = observation.id;
      this.stationId = observation.stationId;
      this.timestamp = observation.timestamp;
      this.items.setReadOnlyMap(observation.items);
      return this;
    }
    
    public Builder setId(long id) {
      this.id = id;
      return this;
    }
    
    public Builder setTimestamp(long timestamp) {
      this.timestamp = timestamp;
      return this;
    }
    
    public Builder setStationId(long stationId) {
      this.stationId = stationId;
      return this;
    }
    
    /**
     * Add an observation item.
     * @param key the field name
     * @param value either String or Double depending on field type.
     * @return reference to this builder.
     */
    public Builder putItem(String key, Object value) {
      if (value instanceof Double || value instanceof String) {
        items.put(key, value);
        return this;
      } else {
        throw new IllegalArgumentException("Item values must be either String or Double");
      }
    }
    
    /**
     * Clears all observation items.
     */
    public Builder clearItems() {
      items.clear();
      return this;
    }
    
    public Observation build() {
      return new Observation(this);
    }
    
    Builder unmarshall(JSONObject json) throws JSONException {
      id = json.getLong(ID);
      stationId = json.getLong(STATION_ID);
      timestamp = json.getLong(COLLECTION_DATE);
      JSONObject data = json.getJSONObject(DATA);
      Iterator<?> keys = data.keys();
      items.clear();
      while (keys.hasNext()) {
        String key = keys.next().toString();
        items.put(key, getStringOrDouble(data, key));
      }
      return this;
    }
    
    private Builder() {
    }
  }
  
  static Map<Long, List<Observation>> fromJSONByStationId(JSONObject json) throws JSONException {
    Builder builder = Observation.newBuilder();
    JSONArray results = json.getJSONArray("results");
    Map<Long, List<Observation>> result = new HashMap<Long, List<Observation>>(); 
    for (int i = 0; i < results.length(); i++) {
      Observation observation = singleFromJSON(builder, results.getJSONObject(i));
      Long stationId = observation.getStationId();
      List<Observation> stationList = result.get(stationId);
      if (stationList == null) {
        stationList = new ArrayList<Observation>();
        result.put(stationId, stationList);
      }
      stationList.add(observation);
    }
    return result;
  }
  
  private Observation(Builder builder) {
    
    // Be sure to update Builder.setObservation() too.
    this.id = builder.id;
    this.stationId = builder.stationId;
    this.timestamp = builder.timestamp;
    this.items = builder.items.build();  
  }
  
  private static Observation singleFromJSON(Builder builder, JSONObject jsonObject) throws JSONException {
    builder.setId(jsonObject.getLong(ID));
    try {
      builder.setTimestamp(TimeUtils.parseJSONDate(jsonObject.getString(COLLECTION_DATE)));
    } catch (ParseException e) {
      throw new JSONException(e);
    }
    JSONObject data = jsonObject.getJSONObject(DATA);
    Iterator<?> keyIterator = data.keys();
    builder.clearItems();
    while (keyIterator.hasNext()) {
      String key = keyIterator.next().toString();
      if (STATION_ID.equals(key)) {
        builder.setStationId(data.getLong(key));
      } else {
        builder.putItem(key, getStringOrDouble(data, key));
      }
    }
    return builder.build();
  }
  
  /**
   * This function is necessary because when you store a double in json and read it back, you could
   * get an int if the original double happened to be a whole number. We want to ensure that our
   * data values are either Strings or Doubles.
   */
  private static Object getStringOrDouble(JSONObject json, String key) throws JSONException {
    Object result = json.get(key);
    if (result instanceof String) {
      return result;
    }
    try {
      return json.getDouble(key);
    } catch (JSONException e) {
      // If we get here, the value is probably the null JSON object.
      return "";
    }
  }
}
